Published on

Domain-Driven Design: A Practical Guide to Alignment, Velocity, and Resilient Systems

Authors
  • avatar
    Name
    Christopher Clemmons
    Twitter

Domain-Driven Design: How to Keep Business and Engineering Aligned

Modern teams do not fail because of frameworks or cloud limits. They fail because the software and the business drift apart. Features take longer, defects rise, and knowledge lives in hallway conversations or ticket comments. Domain-Driven Design (DDD) is a set of strategic and tactical practices that keeps product, domain experts, and engineers working from the same mental model. The result is code that matches how the business really works, and a system that is easier to change without fear.

This article explains why DDD exists, how to apply it in real projects, and what outcomes managers should expect. You will learn the strategic parts that shape your system and the tactical patterns that keep day-to-day code clean.


1) The Core Idea

  • Model the domain, not the database. Focus on the language and rules of the business problem first.
  • Let language drive design. Terms must mean one thing in one place. That language should appear in code, tests, docs, and conversations.
  • Draw boundaries on purpose. Split a large system into Bounded Contexts so each part can evolve at its own pace without breaking others.
  • Use code structures that protect meaning. Entities, Value Objects, Aggregates, and Domain Events codify rules and behavior.

Outcome for managers:

  • Clear ownership, fewer cross-team collisions, faster delivery of changes that matter to customers.

2) Strategic DDD: Designing the System Around the Business

2.1 Bounded Contexts

A Bounded Context is a clear boundary where a domain model and its language apply. Inside the boundary, words have one meaning and rules are consistent. Outside, do not assume the same meaning.

Examples:

  • Billing context uses “Invoice”, “Payment”, “Chargeback”.
  • Care Delivery context uses “Encounter”, “Provider”, “Order”.
  • Identity context uses “Member”, “Authentication”, “Consent”.

Signals you need a boundary:

  • The same term carries different rules in different places.
  • Teams ship independently and step on each other’s data.
  • One schema change requires a half-dozen PRs across unrelated services.

2.2 Context Map Patterns

Bounded Contexts rarely live alone. A Context Map makes their relationships explicit.

  • Customer–Supplier. One context depends on another that provides capabilities. Negotiate interfaces and SLAs.
  • Conformist. Accept the upstream model as is when negotiation is not possible.
  • Anticorruption Layer (ACL). Translate between models to avoid leaking upstream terms and mistakes.
  • Shared Kernel. Share a small, carefully owned model only when both teams can coordinate closely.
  • Separate Ways. Do not integrate if the value is low.

Outcome for managers:

  • Predictable team interactions, fewer emergency syncs, cleaner handoffs during scaling and hiring.

3) Tactical DDD: Patterns You Use Every Day

3.1 Entities and Value Objects

  • Entity. Has identity that persists through change. Example: Order, Patient, Account.
  • Value Object. Identity by value, immutable, small and precise. Example: Money, Email, DateRange.

3.2 Aggregates and Invariants

An Aggregate is a cluster of domain objects with rules enforced at a single transactional boundary. One object is the Aggregate Root.

  • Keep aggregates small so rules are easy to enforce.
  • Change inside an aggregate is one transaction. Changes across aggregates use events or processes.

3.3 Repositories and Services

  • Repository. Collection-like interface for loading and saving aggregates.
  • Domain Service. Domain logic that does not naturally sit on a single entity.
  • Application Service. Orchestrates use cases. Coordinates aggregates, repositories, and external systems.

3.4 Domain Events

Events capture facts that happened in the domain. They keep history, enable integration, and drive eventual consistency.


4) Example Domain: Orders and Payments

A simple pair of contexts that show alignment without sharing databases.

Contexts

  • Ordering. Accepts carts, creates Orders, reserves inventory.
  • Payments. Authorizes and captures money, handles chargebacks.

Context Map

  • Ordering publishes OrderPlaced, OrderCancelled.
  • Payments listens and replies with PaymentAuthorized, PaymentFailed.
  • An ACL in Ordering maps PaymentAuthorized into OrderPaid.

5) Sample Code: TypeScript Implementation (Hexagonal Style)

// domain/value-objects/money.ts
export class Money {
  private constructor(
    private readonly cents: number,
    private readonly currency: 'USD' | 'EUR'
  ) {}
  static of(cents: number, currency: 'USD' | 'EUR') {
    if (cents < 0) throw new Error('Amount cannot be negative')
    return new Money(cents, currency)
  }
  add(other: Money) {
    if (this.currency !== other.currency) throw new Error('Currency mismatch')
    return Money.of(this.cents + other.cents, this.currency)
  }
  get value(): { cents: number; currency: string } {
    return { cents: this.cents, currency: this.currency }
  }
}

// domain/entities/order.ts
export type OrderId = string

export class OrderLine {
  constructor(
    readonly sku: string,
    readonly qty: number,
    readonly price: Money
  ) {
    if (qty <= 0) throw new Error('Quantity must be > 0')
  }
  lineTotal(): Money {
    return this.price
      .add(Money.of(0, this.price.value.currency as 'USD' | 'EUR'))
      .add(
        Money.of(
          (this.qty - 1) * this.price.value.cents,
          this.price.value.currency as 'USD' | 'EUR'
        )
      )
  }
}

export class Order {
  private status: 'PENDING' | 'PAID' | 'CANCELLED' = 'PENDING'
  private constructor(
    readonly id: OrderId,
    private readonly lines: OrderLine[]
  ) {}
  static create(id: OrderId, lines: OrderLine[]) {
    if (lines.length === 0) throw new Error('Order must have at least one line')
    return new Order(id, lines)
  }
  total(): Money {
    return this.lines.reduce(
      (sum, l) => sum.add(Money.of(l.lineTotal().value.cents, sum.value.currency as 'USD' | 'EUR')),
      Money.of(0, this.lines[0].price.value.currency as 'USD' | 'EUR')
    )
  }
  markPaid() {
    if (this.status !== 'PENDING') throw new Error('Only pending orders can be paid')
    this.status = 'PAID'
    return { type: 'OrderPaid' as const, orderId: this.id, total: this.total().value }
  }
  cancel() {
    if (this.status === 'PAID') throw new Error('Paid orders cannot be cancelled')
    this.status = 'CANCELLED'
  }
  get snapshot() {
    return { id: this.id, status: this.status }
  }
}

// application/ports.ts
export interface PaymentGateway {
  authorize(
    orderId: string,
    amountCents: number,
    currency: string
  ): Promise<'AUTHORIZED' | 'DECLINED'>
}
export interface OrderRepository {
  get(id: string): Promise<Order>
  save(order: Order, events: any[]): Promise<void>
}

// application/services/pay-order.ts
export class PayOrderService {
  constructor(
    private readonly repo: OrderRepository,
    private readonly payments: PaymentGateway
  ) {}
  async execute(orderId: string) {
    const order = await this.repo.get(orderId)
    const total = order.total().value
    const result = await this.payments.authorize(orderId, total.cents, total.currency)
    if (result === 'AUTHORIZED') {
      const event = order.markPaid()
      await this.repo.save(order, [event])
      return event
    }
    return { type: 'PaymentDeclined' as const, orderId }
  }
}